引入 在上一回中,跳转到操作系统的骨架代码main.c中的main方法了, 数一数看,总共也就 20 几行代码。但这的确是操作系统启动流程的全部秘密了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 void main (void ) { ROOT_DEV = ORIG_ROOT_DEV; drive_info = DRIVE_INFO; memory_end = (1 <<20 ) + (EXT_MEM_K<<10 ); memory_end &= 0xfffff000 ; if (memory_end > 16 *1024 *1024 ) memory_end = 16 *1024 *1024 ; if (memory_end > 12 *1024 *1024 ) buffer_memory_end = 4 *1024 *1024 ; else if (memory_end > 6 *1024 *1024 ) buffer_memory_end = 2 *1024 *1024 ; else buffer_memory_end = 1 *1024 *1024 ; main_memory_start = buffer_memory_end; mem_init(main_memory_start,memory_end); trap_init(); blk_dev_init(); chr_dev_init(); tty_init(); time_init(); sched_init(); buffer_init(buffer_memory_end); hd_init(); floppy_init(); sti(); move_to_user_mode(); if (!fork()) { init(); } for (;;) pause(); }
主内存初始化mem_init 首先设置了内存的边界,包含memory和buffer,边界的设置是根据不同的内存大小设置的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #define LOW_MEM 0x100000 #define PAGING_MEMORY (15*1024*1024) #define PAGING_PAGES (PAGING_MEMORY>>12) #define MAP_NR(addr) (((addr)-LOW_MEM)>>12) #define USED 100 static long HIGH_MEMORY = 0 ;static unsigned char mem_map[PAGING_PAGES] = { 0 , };void mem_init (long start_mem, long end_mem) { int i; HIGH_MEMORY = end_mem; for (i=0 ; i<PAGING_PAGES ; i++) mem_map[i] = USED; i = MAP_NR(start_mem); end_mem -= start_mem; end_mem >>= 12 ; while (end_mem-->0 ) mem_map[i++]=0 ; }
如何管理内存?
举个例子 比如我们在 fork 子进程的时候,会调用 copy_process 函数来复制进程的结构信息,其中有一个步骤就是要申请一页内存 ,用于存放进程结构信息 task_struct。该内存的申请就是选择 mem_map 中首个空闲页面,并标记为已使用。
1 2 3 4 5 6 int copy_process (...) { struct task_struct *p ; ... p = (struct task_struct *) get_free_page(); ... }
中断初始化trap_init 当你的计算机刚刚启动时,你按下键盘是不生效的,但是过了一段时间后,再按下键盘就有效果了。如何首先的呢,多久会生效呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 void trap_init (void ) { int i; set_trap_gate(0 ,÷_error); set_trap_gate(1 ,&debug); set_trap_gate(2 ,&nmi); set_system_gate(3 ,&int3); set_system_gate(4 ,&overflow); set_system_gate(5 ,&bounds); set_trap_gate(6 ,&invalid_op); set_trap_gate(7 ,&device_not_available); set_trap_gate(8 ,&double_fault); set_trap_gate(9 ,&coprocessor_segment_overrun); set_trap_gate(10 ,&invalid_TSS); set_trap_gate(11 ,&segment_not_present); set_trap_gate(12 ,&stack_segment); set_trap_gate(13 ,&general_protection); set_trap_gate(14 ,&page_fault); set_trap_gate(15 ,&reserved); set_trap_gate(16 ,&coprocessor_error); for (i=17 ;i<48 ;i++) set_trap_gate(i,&reserved); set_trap_gate(45 ,&irq13); set_trap_gate(39 ,¶llel_interrupt); }
TIPS:set_trap_gate
和set_system_gate
什么关系?
这个 trap 与 system 的区别仅仅在于,设置的中断描述符的特权级不同,前者是 0(内核态),后者是 3(用户态),这块展开将会是非常严谨的、绕口的、复杂的特权级相关的知识,不明白的话先不用管,就理解为都是设置一个中断号和中断处理程序的对应关系就好了。
1 2 3 4 5 #define set_trap_gate(n,addr) \ _set_gate(&idt[n],15,0,addr) #define set_system_gate(n,addr) \ _set_gate(&idt[n],15,3,addr)
_set_gate就是设置一些列的硬中断处理函数,执行过后,IDT指向的结构变成了:
什么时候开始中断呢?sti();
也就是这时候开始,按下键盘可以有反应了。
块设备请求项初始化 blk_dev_init 用于让我们能够从硬盘读取数据到磁盘,读取块设备与内存缓冲区之间的桥梁
1 2 3 4 5 6 7 void blk_dev_init (void ) { int i; for (i=0 ; i<32 ; i++) { request[i].dev = -1 ; request[i].next = NULL ; } }
其中request请求,代表着一次读盘请求
1 2 3 4 5 6 7 8 9 10 11 struct request { int dev; int cmd; int errors; unsigned long sector; unsigned long nr_sectors; char * buffer; struct task_struct * waiting ; struct buffer_head * bh ; struct request * next ; };
这个 request 结构可以完整描述一个读盘操作。然后那个 request 数组就是把它们都放在一起,并且它们又通过 next 指针串成链表。
如何添加到request链 sys_read 核心代码
1 2 3 4 5 6 7 8 9 int sys_read (unsigned int fd,char * buf,int count) { struct file * file = current->filp[fd]; struct m_inode * inode = file->f_inode; verify_area(buf,count); return file_read(inode,file,buf,count); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 int file_read (struct m_inode * inode, struct file * filp, char * buf, int count) { int left,chars,nr; struct buffer_head * bh ; left = count; while (left) { if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) { if (!(bh=bread(inode->i_dev,nr))) break ; } else bh = NULL ; nr = filp->f_pos % BLOCK_SIZE; chars = MIN( BLOCK_SIZE-nr , left ); filp->f_pos += chars; left -= chars; if (bh) { char * p = nr + bh->b_data; while (chars-->0 ) put_fs_byte(*(p++),buf++); brelse(bh); } else { while (chars-->0 ) put_fs_byte(0 ,buf++); } } inode->i_atime = CURRENT_TIME; return (count-left)?(count-left):-ERROR; }
接着就是不断的读取数据到我们的buf中,从代码中可以看到是从buffer_head * bh
中读的,这是什么?
1 2 3 4 5 6 7 8 9 10 11 struct buffer_head * bread (int dev,int block) { struct buffer_head * bh = getblk(dev,block); if (bh->b_uptodate) return bh; ll_rw_block(READ,bh); wait_on_buffer(bh); if (bh->b_uptodate) return bh; brelse(bh); return NULL ; }
其中 getblk 先申请了一个内存中的缓冲块,然后 ll_rw_block 负责把数据读入这个缓冲块,进去继续看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void ll_rw_block (int rw, struct buffer_head * bh) { ... make_request(major,rw,bh); }static void make_request (int major,int rw, struct buffer_head * bh) { ...if (rw == READ) req = request+NR_REQUEST; else req = request+((NR_REQUEST*2 )/3 ); while (--req >= request) if (req->dev<0 ) break ; ... req->dev = bh->b_dev; req->cmd = rw; req->errors=0 ; req->sector = bh->b_blocknr<<1 ; req->nr_sectors = 2 ; req->buffer = bh->b_data; req->waiting = NULL ; req->bh = bh; req->next = NULL ; add_request(major+blk_dev,req); }
ll_rw_block会往刚刚的设备的请求项链表 request[32] 中添加一个请求项,作为访问块设备和内存缓冲区之间的桥梁
请求队列中的请求由I/O调度器进行管理。I/O调度器负责确定请求的执行顺序,优化整体的磁盘性能和响应时间。Linux提供了多种I/O调度器,如CFQ(完全公平队列)、Deadline、NOOP等,不同的调度器适用于不同类型的工作负载和硬件配置。
控制台初始化 tty_init 1 2 3 4 5 void tty_init (void ) { rs_init(); con_init(); }
1 2 3 4 5 6 7 8 9 10 11 12 13 void con_init (void ) { ... if (ORIG_VIDEO_MODE == 7 ) { ... if ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...} else {...} } else { ... if ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...} else {...} } ... }
非常多的 if else。为了应对不同的显示模式,来分配不同的变量值,那如果我们仅仅找出一个显示模式,这些分支就可以只看一个了。
如何显示一个字符 啥是显示模式呢?那我们得简单说说显示,一个字符是如何显示在屏幕上的呢 ?换句话说,如果你可以随意操作内存和 CPU 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢?
内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。mov [0xB8000],'h'
代码 假设显示模式是我们现在的这种文本模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #define ORIG_X (*(unsigned char *)0x90000) #define ORIG_Y (*(unsigned char *)0x90001) void con_init (void ) { register unsigned char a; video_num_columns = (((*(unsigned short *)0x90006 ) & 0xff00 ) >> 8 ); video_size_row = video_num_columns * 2 ; video_num_lines = 25 ; video_page = (*(unsigned short *)0x90004 ); video_erase_char = 0x0720 ; video_mem_start = 0xb8000 ; video_port_reg = 0x3d4 ; video_port_val = 0x3d5 ; video_mem_end = 0xba000 ; origin = video_mem_start; scr_end = video_mem_start + video_num_lines * video_size_row; top = 0 ; bottom = video_num_lines; gotoxy(ORIG_X, ORIG_Y); set_trap_gate(0x21 ,&keyboard_interrupt); outb_p(inb_p(0x21 )&0xfd ,0x21 ); a=inb_p(0x61 ); outb_p(a|0x80 ,0x61 ); outb(a,0x61 ); }
1 2 3 4 5 6 static inline void gotoxy (unsigned int new_x,unsigned int new_y) { ... x = new_x; y = new_y; pos = origin + y*video_size_row + (x<<1 ); }
x 表示光标在哪一列,y 表示光标在哪一行,pos 表示根据列号和行号计算出来的内存指针,也就是往这个 pos 指向的地址处写数据,就相当于往控制台的 x 列 y 行处写入字符了
键盘中断 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 _keyboard_interrupt: ... call _do_tty_interrupt ... void do_tty_interrupt (int tty) { copy_to_cooked(tty_table+tty); }void copy_to_cooked (struct tty_struct * tty) { ... tty->write(tty); ... }void con_write (struct tty_struct * tty) { ... __asm__("movb _attr,%%ah\n\t" "movw %%ax,%1\n\t" ::"a" (c),"m" (*(short *)pos) :"ax" ); pos += 2 ; x++; ... }
至此我们可以实现显示功能了,本质就是往内存中pos位置写值,那回车 、换行 、删除 、滚屏 、清屏 等操作,其实底层都操作x y pos,然后修改内存就行,并对外暴露小功能函数
在此之后,内核代码就可以用它来方便地在控制台输出字符啦!这在之后内核想要在启动过程中告诉用户一些信息,以及后面内核完全建立起来之后,由用户用 shell 进行操作时手动输入命令,都是可以用到这里的代码的!
printf 这样的库函数,在屏幕上输出信息,同时支持换行和滚屏等友好设计,这些都是 tty_init 初始化,以及其对外封装的小功能函数,来实现的。
时间初始化 time_init
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #define CMOS_READ(addr) ({ \ outb_p(0x80|addr,0x70); \ inb_p(0x71); \ }) #define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10) static void time_init (void ) { struct tm time ; do { time.tm_sec = CMOS_READ(0 ); time.tm_min = CMOS_READ(2 ); time.tm_hour = CMOS_READ(4 ); time.tm_mday = CMOS_READ(7 ); time.tm_mon = CMOS_READ(8 ); time.tm_year = CMOS_READ(9 ); } while (time.tm_sec != CMOS_READ(0 )); BCD_TO_BIN(time.tm_sec); BCD_TO_BIN(time.tm_min); BCD_TO_BIN(time.tm_hour); BCD_TO_BIN(time.tm_mday); BCD_TO_BIN(time.tm_mon); BCD_TO_BIN(time.tm_year); time.tm_mon--; startup_time = kernel_mktime(&time); }
1 2 3 4 5 #define CMOS_READ(addr) ({ \ outb_p(0x80|addr,0x70); \ inb_p(0x71); \ })
这是 CPU 与外设交互的一个基本玩法,CPU 与外设打交道基本是通过端口,往某些端口写值来表示要这个外设干嘛,然后从另一些端口读值来接受外设的反馈。
以磁盘为例:读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0 端口一个字节一个字节的读数据。这就完成了一次硬盘读操作。
在 0x1F2 写入要读取的扇区数
在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址
在 0x1F7 处写入读命令的指令号
不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位
如果第四步骤为不忙,则开始不断从 0x1F0 处读取数据到内存指定位置,直到读完
当然,读取硬盘的这个无脑循环,可以 CPU 直接读取并做写入内存的操作,这样就会占用 CPU 的计算资源。
也可以交给 DMA 设备去读,解放 CPU,但和硬盘的交互,通通都是按照硬件手册上的端口说明,来操作的,实际上也是做了一层封装。
而在时间读取中,就是和CMOS (主板上的一个可读写的 RAM)这个外设打交道,让他告诉我们时间
CMOS:计算机主板上的一小块芯片,这块芯片使用CMOS技术来存储BIOS(基本输入输出系统)设置等基础系统信息。这些信息包括系统时间、硬件配置设置等,这部分内存被称为CMOS RAM或非易失性BIOS内存。由于CMOS技术的低功耗特点,即使在计算机断电后,CMOS内存也能通过一个小电池供电维持数据存储。这使得计算机在下一次开机时可以记住之前的配置设置。
这方法可了不起,因为它就是多进程的基石!
终于来到了兴奋的时刻,是不是很激动?不过先别激动,这里只是进程调度的初始化,也就是为进程调度所需要用到的数据结构做个准备,真正的进程调度还需要调度算法、时钟中断等机制的配合。
当然,对于理解操作系统,流程和数据结构最为重要了,而这一段作为整个流程的起点,以及建立数据结构的地方,就显得格外重要了。
1 2 3 4 5 6 void sched_init (void ) { set_tss_desc(gdt+4 , &(init_task.task.tss)); set_ldt_desc(gdt+5 , &(init_task.task.ldt)); ... } 往后加了两项:TSS和LDT 这是代表一个线程0 ,如果由其他线程还要继续加
TSS TSS 在计算机中代表任务状态段 (Task State Segment)。它是一种数据结构,用于存储处理器在任务切换时必须保存的特定任务的状态信息(寄存器的值)。每个任务都有一个对应的 TSS。当操作系统执行任务切换时,它会使用 TSS 来保存当前任务的状态,并加载新任务的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 struct tss_struct { long back_link; long esp0; long ss0; long esp1; long ss1; long esp2; long ss2; long cr3; long eip; long eflags; long eax, ecx, edx, ebx; long esp; long ebp; long esi; long edi; long es; long cs; long ss; long ds; long fs; long gs; long ldt; long trace_bitmap; struct i387_struct i387 ; };
LDT LDT 叫局部描述符表 (Local Descriptor Table),是与 GDT 全局描述符表相对应的,内核态的代码用 GDT 里的数据段和代码段,而用户进程的代码用每个用户进程自己的 LDT 里得数据段和代码段。
task[]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct desc_struct { unsigned long a,b; }struct task_struct * task [64] = {&(init_task.task), };void sched_init (void ) { ... int i; struct desc_struct * p ; p = gdt+6 ; for (i=1 ;i<64 ;i++) { task[i] = NULL ; p->a=p->b=0 ; p++; p->a=p->b=0 ; p++; } ... }
这个 task_struct 结构就是代表每一个进程的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 struct task_struct { long state; long counter; long priority; long signal; struct sigaction sigaction [32]; long blocked; int exit_code; unsigned long start_code,end_code,end_data,brk,start_stack; long pid,father,pgrp,session,leader; unsigned short uid,euid,suid; unsigned short gid,egid,sgid; long alarm; long utime,stime,cutime,cstime,start_time; unsigned short used_math; int tty; unsigned short umask; struct m_inode * pwd ; struct m_inode * root ; struct m_inode * executable ; unsigned long close_on_exec; struct file * filp [NR_OPEN ]; struct desc_struct ldt [3]; struct tss_struct tss ; };
接下来告诉通过寄存器告诉系统当前任务的LDT,TSS的位置(内存里每个线程都有LDT,TSS,当前线程的是哪一个),初始指向了第0个
1 2 3 4 5 6 void sched_init (void ) { ... ltr(0 ); lldt(0 ); ... }
最后 1 2 3 4 5 6 7 8 9 10 11 12 void sched_init (void ) { ... outb_p(0x36 ,0x43 ); outb_p(LATCH & 0xff , 0x40 ); outb(LATCH >> 8 , 0x40 ); set_intr_gate(0x20 ,&timer_interrupt); outb(inb_p(0x21 )&~0x01 ,0x21 ); set_system_gate(0x80 ,&system_call); ... }
四行端口读写代码,两行设置中断代码。
端口读写我们已经很熟悉了,就是 CPU 与外设交互的一种方式,之前讲硬盘读写以及 CMOS 读写时,已经接触过了。
而这次交互的外设是一个可编程定时器 的芯片,这四行代码就开启了这个定时器,之后这个定时器变会持续的、以一定频率的向 CPU 发出中断信号 。
第一个就是时钟中断 ,中断号为 0x20 ,中断处理程序为 timer_interrupt 。那么每次定时器向 CPU 发出中断后,便会执行这个函数。
更新系统时间或运行时间计数器。
检查和执行定时任务或超时事件(网络通信和用户交互)。
对当前运行的进程或线程的运行时间进行计量,以便进行任务调度。
是操作系统主导进程调度的一个关键!
第二个设置的中断叫系统调用 system_call ,中断号是 0x80 ,这个中断又是个非常非常非常非常非常非常非常重要的中断,所有用户态程序想要调用内核提供的方法,都需要基于这个系统调用来进行。
Java 程序员写一个 read,底层会执行汇编指令 int 0x80 ,这就会触发系统调用这个中断,最终调用到 Linux 里的 sys_read 方法。
中断号
中断处理函数
0 ~ 0x10 (trap_init)
trap_init 里设置的一堆
0x20
timer_interrupt
0x21 (tty_init)
keyboard_interrupt
0x80
system_call
找到些感觉没,有没有越来越发现,操作系统有点靠中断驱动的意思,各个模块不断初始化各种中断处理函数,并且开启指定的外设开关,让操作系统自己慢慢“活”了起来,逐渐通过中断忙碌于各种事情中,无法自拔。
小结
我们往GDT中写入了TSS和LDT,作为每一个线程的任务状态和局部描述符表 初始: init_task.task.tss init_task.task.ldt
初始化了task_struct [] ,并且第一个位置init_task.task
设置了时钟中断0x20和系统调用0x80
在讲通过文件系统来读取硬盘文件时,都需要使用和弃用这个缓冲区里的内容,缓冲区即是用户进程的内存和硬盘之间的桥梁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 extern int end; struct buffer_head * start_buffer = (struct buffer_head *) &end;struct buffer_head { unsigned long b_state; char *b_data; struct block_device *b_dev ; unsigned long b_blocknr; struct buffer_head *b_this_page ; struct buffer_head *b_this_free ; struct buffer_head *b_next ; atomic_t b_count; };void buffer_init (long buffer_end) { struct buffer_head * h = start_buffer; void * b = (void *) buffer_end; while ( (b -= 1024 ) >= ((void *) (h+1 )) ) { h->b_dev = 0 ; h->b_dirt = 0 ; h->b_count = 0 ; h->b_lock = 0 ; h->b_uptodate = 0 ; h->b_wait = NULL ; h->b_next = NULL ; h->b_prev = NULL ; h->b_data = (char *) b; h->b_prev_free = h-1 ; h->b_next_free = h+1 ; h++; } h--; free_list = start_buffer; free_list->b_prev_free = h; h->b_next_free = free_list; for (int i=0 ;i<307 ;i++) hash_table[i]=NULL ; }
缓冲头记录下头信息,并通过pre next串联起来
b_data指向真正的数据区
读取块设备的数据(硬盘中的数据),需要先读到缓冲区中,如果缓冲区已有了,就不用从块设备读取了,直接取走
怎么知道缓冲区已经有了要读取的块设备中的数据呢? 遍历效率太低,直接hash
key是什么? (设备号^逻辑块号) Mod 307
冲突用链表解决
如何实现淘汰呢?哈希表 + 双向链表 实现LRU
hd_init 是硬盘初始化 ,我们不得不看
floppy_init 是软盘初始化 ,现在软盘几乎都被淘汰了,计算机中也没有软盘驱动器了,所以这个我们完全可以不看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct blk_dev_struct blk_dev [NR_BLK_DEV ] = { { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL }, { NULL , NULL } };void hd_init (void ) { blk_dev[3 ].request_fn = do_hd_request; set_intr_gate(0x2E ,&hd_interrupt); outb_p(inb_p(0x21 )&0xfb ,0x21 ); outb(inb_p(0xA1 )&0xbf ,0xA1 ); }
往某些 IO 端口上读写一些数据,表示开启它;
然后再向中断向量表中添加一个中断,使得 CPU 能够响应这个硬件设备的动作;
最后再初始化一些数据结构来管理。不过像是内存管理可能结构复杂些,外设的管理,相对就简单很多了。
操作系统就是一个靠中断驱动的死循环而已 ,如果不发生任何中断,操作系统会一直在一个死循环里等待。换句话说,让操作系统工作的唯一方式,就是触发中断。
硬盘端口 端口对应硬盘控制器上的寄存器
端口
读
写
0x1F0
数据寄存器
数据寄存器
0x1F1
错误寄存器
特征寄存器
0x1F2
扇区计数寄存器
扇区计数寄存器
0x1F3
扇区号寄存器或 LBA 块地址 0~7
扇区号或 LBA 块地址 0~7
0x1F4
磁道数低 8 位或 LBA 块地址 8~15
磁道数低 8 位或 LBA 块地址 8~15
0x1F5
磁道数高 8 位或 LBA 块地址 16~23
磁道数高 8 位或 LBA 块地址 16~23
0x1F6
驱动器/磁头或 LBA 块地址 24~27
驱动器/磁头或 LBA 块地址 24~27
0x1F7
命令寄存器或状态寄存器
命令寄存器
那读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0
端口一个字节一个字节的读数据。这就完成了一次硬盘读操作。
如果觉得不够具体,那来个具体的版本。